fix(wasm): extract call-site AST nodes in ast-store-visitor#678
fix(wasm): extract call-site AST nodes in ast-store-visitor#678carlos-alm merged 4 commits intomainfrom
Conversation
Add `call_expression: 'call'` to the WASM `astTypes` map and implement `extractCallName` in the ast-store visitor to match the native engine's call-site extraction. Un-skip the ast_nodes parity test now that both engines produce identical results.
|
Claude finished @carlos-alm's task —— View job PR Review: WASM Call-Site AST Node ExtractionReview Checklist:
Root Cause AnalysisProblem Being Solved: Fixing WASM/native engine parity for AST node extraction. The WASM engine was missing call-site extraction while the native engine had it, causing the Why This Approach: The fix correctly mirrors the native engine's approach by:
Risk Assessment: LOW - This is a targeted fix that brings WASM behavior in line with native. No breaking changes. Backlog Compliance
Code Quality Assessment ✅Excellent implementation. The WASM Native (Rust): for field in &["function", "method", "name"] {
if let Some(fn_node) = node.child_by_field_name(field) {
return node_text(&fn_node, source).to_string();
}
}
text.split('(').next().unwrap_or("?").to_string()WASM (TypeScript): const fn = node.childForFieldName('function');
if (fn) return fn.text;
return node.text?.split('(')[0] || '?';✅ Correct field prioritization: Both check Critical ConcernsNone. Final Recommendation
The PR should be merged immediately. Engine parity issues like this are high priority — they ensure codegraph delivers consistent results regardless of which engine is used, which is essential for reliability in AI agent workflows. |
Greptile SummaryThis PR closes issue #674 by implementing call-site AST node extraction in the WASM engine's Key changes:
Note: the parity test's Confidence Score: 5/5Safe to merge — both prior P1 issues are fully addressed and the parity test now passes with all four assertions. All previously identified P1 issues (arguments-only recursion to prevent double-counting chained calls, and the missing field fallbacks in extractCallName) were fixed in a67cb64 and are confirmed in the current HEAD. No new P0 or P1 issues found. The only remaining observation is a pre-existing test scope limitation (text/receiver columns excluded from parity comparison), which is P2 at most. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["DFS Walker: enterNode(node)"] --> B{node in matched?}
B -- yes --> Z[return undefined\nDFS continues into children]
B -- no --> C{kind in astTypeMap?}
C -- no --> Z
C -- yes --> D["collectNode(node, kind)\nmarks node in matched"]
D --> E{kind === 'call'?}
E -- yes --> F["walkCallArguments(callNode)"]
F --> G["find arguments field\n(field-based ?? kind-based fallback)"]
G --> H{argsNode found?}
H -- no --> I[return]
H -- yes --> J["walkSubtree(child)\nfor each child of argsNode"]
J --> K{child in matched?}
K -- yes --> L[return]
K -- no --> M{child kind?}
M -- "'call'" --> N["collectNode(child)\nwalkCallArguments(child)\nreturn — isolates nested chains"]
M -- "string/regex" --> O["collectNode(child)\nrecurse into children"]
M -- "other known kind" --> P["collectNode(child)\nreturn — skipChildren"]
M -- "unknown kind" --> Q["recurse into all children\n(finds deeper calls)"]
E -- no --> R{kind === string\nor regex?}
R -- no --> S["return { skipChildren: true }"]
R -- yes --> Z
Reviews (2): Last reviewed commit: "test(fixture): add chained-call pattern ..." | Re-trigger Greptile |
| if (kind !== 'string' && kind !== 'regex' && kind !== 'call') { | ||
| return { skipChildren: true }; | ||
| } |
There was a problem hiding this comment.
Recursing into all children diverges from native for chained calls
The native engine (see helpers.rs lines 254–275) explicitly states "Do NOT recurse children: prevents double-counting nested calls" and then recurses only into the arguments field, not into the function field. The WASM implementation here skips skipChildren for call nodes entirely, which causes the visitor to traverse the full subtree, including the function field.
This means for any chained-call expression such as service.getUser().getName():
call_expression ← captured ✓
function: member_expression
object: call_expression ← also captured by WASM (incorrect!)
"service.getUser()"
property: getName
arguments: ()
The native engine emits 1 ast_node for this expression; the WASM engine will emit 2 — one for the outer call and one for the inner service.getUser() that is in the function field, not the arguments.
The parity test passes today only because the sample fixture (index.js, utils.js) never has a call expression in the function field of another call (all chained accesses use plain member_expression on identifiers like calc.compute, console.log). Real-world TypeScript/JavaScript with fluent APIs, Promises (.then().catch()), array methods (.filter().map()) or method chaining will produce extra, spurious ast_nodes compared to the native engine.
The fix should mirror the native approach: return { skipChildren: true } for call nodes and then manually iterate over only the arguments field's children — or add a mechanism to mark the function-field subtree so that contained call expressions are pre-inserted into matched and therefore skipped.
There was a problem hiding this comment.
Fixed in a67cb64. The ast-store-visitor now mirrors the native engine's arguments-only recursion strategy:
skipChildren: trueis returned for call nodes, preventing the general DFS walker from descending into the full subtree (including thefunctionfield).walkCallArguments()manually recurses only into theargumentsfield children, usingchildForFieldName('arguments')with a kind-based fallback (matching the native approach inhelpers.rs:254-275).- A
walkSubtree()helper handles the recursive traversal within arguments, applying the same call-node isolation recursively for deeply nested calls.
This means for service.getUser().getName(), only the outer call is captured (the inner service.getUser() in the function field is skipped), matching native behavior exactly.
Additionally, a chained-call fixture (items.filter(Boolean).map(String)) was added to tests/fixtures/sample-project/utils.js in e412edf to ensure the parity test exercises this scenario going forward.
| function extractCallName(node: TreeSitterNode): string { | ||
| const fn = node.childForFieldName('function'); | ||
| if (fn) return fn.text; | ||
| return node.text?.split('(')[0] || '?'; | ||
| } |
There was a problem hiding this comment.
Missing field fallbacks compared to native
extract_call_name
The native Rust implementation (helpers.rs line 442) checks three field names in order — "function", "method", and "name" — to accommodate different Tree-sitter grammars:
for field in &["function", "method", "name"] {
if let Some(fn_node) = node.child_by_field_name(field) {
return node_text(&fn_node, source).to_string();
}
}The WASM extractCallName only checks "function". While the JavaScript/TypeScript Tree-sitter grammar always uses "function" for call_expression, if this helper is ever reused for another grammar (e.g., Python uses "function" but others use "method" or "name"), it would silently fall back to the text-split heuristic instead of the precise field lookup.
| function extractCallName(node: TreeSitterNode): string { | |
| const fn = node.childForFieldName('function'); | |
| if (fn) return fn.text; | |
| return node.text?.split('(')[0] || '?'; | |
| } | |
| function extractCallName(node: TreeSitterNode): string { | |
| for (const field of ['function', 'method', 'name']) { | |
| const fn = node.childForFieldName(field); | |
| if (fn) return fn.text; | |
| } | |
| return node.text?.split('(')[0] || '?'; | |
| } |
There was a problem hiding this comment.
Fixed in a67cb64. extractCallName now checks all three fields in the same order as the native extract_call_name in helpers.rs:442:
function extractCallName(node: TreeSitterNode): string {
for (const field of ['function', 'method', 'name']) {
const fn = node.childForFieldName(field);
if (fn) return fn.text;
}
return node.text?.split('(')[0] || '?';
}This matches the native Rust implementation exactly and ensures grammar compatibility across languages.
… name extraction (#678) Mirror the native engine's call-site handling in ast-store-visitor: - Return skipChildren for call nodes, recurse only into arguments subtree to prevent double-counting chained calls like a().b() - Check function/method/name fields in extractCallName to match native extract_call_name fallback order
…field parity (#678) Add formatResults() with items.filter(Boolean).map(String) to the sample-project fixture. This ensures the parity test catches divergence in chained call handling between WASM and native engines.
|
Claude finished @carlos-alm's task —— View job PR Review: WASM Call-Site AST Node Extraction ✅Todo List:
Comprehensive Analysis✅ P1 Issue Resolution: Recursion StrategyProblem: WASM engine was recursing into all children (including Native Implementation ( // Do NOT recurse children: prevents double-counting nested calls like `a(b())`.
if config.call_types.contains(&kind) {
// ... capture call node ...
// Recurse into arguments only
let args_node = node.child_by_field_name("arguments").or_else(|| {
// fallback to kind-based matching for different grammars
for i in 0..node.child_count() {
let ck = child.kind();
if ck == "arguments" || ck == "argument_list" || ck == "method_arguments" {
return Some(child);
}
}
None
});
// recursively walk only arguments subtree
return; // explicitly prevent further traversal
}WASM Implementation ( if (kind === 'call') {
// Mirror native: skip full subtree, recurse only into arguments.
// Prevents double-counting chained calls like service.getUser().getName().
walkCallArguments(node);
return { skipChildren: true };
}Perfect match! The WASM implementation correctly:
✅ P2 Issue Resolution: Field FallbacksNative Implementation ( fn extract_call_name(node: &Node, source: &[u8]) -> String {
for field in &["function", "method", "name"] {
if let Some(fn_node) = node.child_by_field_name(field) {
return node_text(&fn_node, source).to_string();
}
}
// fallback to text split
}WASM Implementation ( function extractCallName(node: TreeSitterNode): string {
for (const field of ['function', 'method', 'name']) {
const fn = node.childForFieldName(field);
if (fn) return fn.text;
}
return node.text?.split('(')[0] || '?';
}Perfect match! Identical field order and fallback logic. ✅ Test Coverage EnhancementFixture Addition ( // Chained call — exercises call-in-function-field (a().b()) parity
function formatResults(items) {
return items.filter(Boolean).map(String);
}This is exactly the test case that would have exposed the original P1 bug. The chained call
Without the fix, WASM would emit 2 ast_nodes vs native's 1. ✅ Implementation Quality AssessmentArchitecture Alignment: The WASM visitor now perfectly mirrors the native engine's strategy:
Code Quality:
Final AssessmentRating: ⭐⭐⭐⭐⭐ (5/5) Recommendation: APPROVE AND MERGE IMMEDIATELY This is exemplary engine parity work that:
The un-skipped parity test ( Priority: High - Engine parity bugs cause inconsistent behavior and break reliability guarantees. |
Summary
Closes #674.
call_expression: 'call'to the WASMastTypesmap insrc/ast-analysis/rules/javascript.ts(shared by JS/TS/TSX)extractCallName()inast-store-visitor.tsthat mirrors the native engine'sextract_call_name— trieschildForFieldName('function')first, falls back to splitting on(callnodes to recurse into children (so nested calls in arguments are extracted)produces identical ast_nodesparity test — all 4 parity assertions now passTest plan
npx vitest run tests/integration/build-parity.test.ts— all 4 tests pass (nodes, edges, roles, ast_nodes)